(function () {
    "use strict";

    require("./assign");
    require("./slice-args");
    var Notifier = require("./notifier");
    var NotifierList = require("./notifier-list");
    var defineType = require("./define-type");
    var PathWalker = require("./path-walker");

    function JsonContext() {
        this.nodesById = {};
        this.idOnly = [];
        this.path = [];
        this.serializedIds = [];
    }

    var jcproto = JsonContext.prototype;

    jcproto.registerIdOnly = function (key, id) {
        var path = this.path.slice();
        path.push(key);
        this.idOnly.push({
            path: PathWalker.join.apply(this, path),
            id: id
        });
    };

    jcproto.applyIdOnly = function (node) {
        this.idOnly.forEach(function (item) {
            var child = this.nodesById[item.id];
            if (!child) {
                throw new Error("invalid json source");
            }

            node.set(item.path, child);
        }, this);
    };

    jcproto.registerId = function (id) {
        if (this.serializedIds.indexOf(id) >= 0) {
            throw new Error("invalid document tree");
        }

        this.serializedIds.push(id);
    };

    jcproto.withPath = function () {
        if (!arguments.length) {
            return;
        }

        var f = arguments[arguments.length - 1];
        if (!(f instanceof Function)) {
            return;
        }

        var path = Function.sliceArgs(arguments, 0, arguments.length - 1);
        path.forEach(function (item) {
            this.path.push(item);
        }, this);
        var result = f();
        path.forEach(this.path.pop.bind(this.path));
        return result;
    };

    function isNaturalNumber(n) {
        return typeof n === "number" && n >= 0;
    }

    function validNodeJson(json) {
        return isNaturalNumber(json.id) &&
            isNaturalNumber(json.version);
    }

    function isIgnored(options, key) {
        return key in options &&
            options[key].ignore;
    }

    function isJsonValue(value) {
        if (value instanceof Function) {
            return false;
        }

        if (value === undefined) {
            return false;
        }

        return true;
    }

    function valueToJson(value, options, context) {
        if (value instanceof Node) {
            if (options.idOnly) {
                return value.id;
            }

            context.registerId(value.id);
            return nodeToJson(value, context);
        }

        if (value instanceof List) {
            return listToJson(value, options, context);
        }

        return value;
    }

    function getNodeType(options, json) {
        if (options.getNodeType) {
            return options.getNodeType(json);
        }

        return options.nodeType;
    }

    function getCached(context, id, childType) {
        var cached = context.nodesById[id];
        if (!cached) {
            return null;
        }

        if (cached.constructor !== childType) {
            throw new Error("invalid json source");
        }

        return cached;
    }

    function nodeToJson(node, context) {
        var json = {};
        for (var key in node) {
            if (isIgnored(node.jsonOptions, key)) {
                continue;
            }

            if (!isJsonValue(node[key])) {
                continue;
            }

            var options = node.jsonOptions[key] || {};
            var jsonValue = valueToJson(node[key], options, context);
            var jsonKey = key;
            if ((options.nodeType || options.getNodeType) && options.idOnly) {
                jsonKey = key + "Id";
                if (jsonValue !== null && jsonValue !== undefined) {
                    json[jsonKey] = jsonValue;
                }
            } else {
                json[jsonKey] = jsonValue;
            }
        }

        return json;
    }

    function createChild(context, json, nodeType) {
        var child = new nodeType(json.id, json.version);
        nodeFromJson(child, json, context);
        return child;
    }

    function childListFromJson(node, json, key, options, context) {
        if (!Array.isArray(json[key])) {
            throw new Error("invalid json source");
        }

        if (node[key] === null || node[key] === undefined) {
            node[key] = new List;
        }

        console.error("getting", key, node.version, json.version);
        var listOptions = Object.assign(options, {
            preserve: (options.nodeType || options.getNodeType) &&
                node.version > json.version
        });

        context.withPath(key, function () {
            console.error("calling list from json", key);
            listFromJson(node[key], json[key], listOptions, context);
        });
    }

    function childNodeFromJson(node, json, key, options, context) {
        if (node[key] && node[key].id === json[key].id) {
            nodeFromJson(node[key], json[key], context);
            return;
        }

        if (node.version > json.version) {
            return;
        }

        if (options.idOnly) {
            context.registerIdOnly(key.substr(0, key.length - 2), json[key]);
            return;
        }

        var nodeType = getNodeType(options, json[key]);
        var cached = getCached(context, json[key].id, nodeType);
        if (cached) {
            node[key] = cached;
            return;
        }

        context.withPath(key, function () {
            node[key] = createChild(context, json[key], nodeType);
        });
    }

    function fieldFromJson(node, json, key, context) {
        var options = node.jsonOptions[key];
        if (!options && key.indexOf("Id") === key.length - 2) {
            options = node.jsonOptions[key.substr(0, key.length - 2)];
        }
        if (!options) {
            options = {};
        }

        if (options.list) {
            childListFromJson(node, json, key, options, context);
            return;
        }

        if (options.nodeType || options.getNodeType) {
            childNodeFromJson(node, json, key, options, context);
            return;
        }

        if (key !== "version" && node.version <= json.version) {
            node[key] = json[key];
        }
    }

    function nodeFromJson(node, json, context) {
        if (!validNodeJson(json) || node.id !== json.id) {
            throw new Error("invalid json source");
        }

        context.registerId(json.id);

        console.error("freezing");
        node.freeze(true);
        for (var key in json) {
            if (isIgnored(node.jsonOptions, key)) {
                continue;
            }

            console.error("field updating", key);
            fieldFromJson(node, json, key, context);
        }

        node.freeze(false);
        console.error("unfreezed");
        node.version = json.version;
        console.error("version set", node.version);
        context.nodesById[node.id] = node;
    }

    function listToJson(list, options, context) {
        list.freeze(true);
        var json = list.items
            .filter(isJsonValue)
            .map(function (item) {
                return valueToJson(item, options, context);
            });
        list.freeze(false);
        return json;
    }

    function listFromJsonPreserving(list, json, context) {
        list.freeze(true);
        list.items.forEach(function (node, index) {
            var jsonItems = json.filter(function (item) {
                return item.id === node.id;
            });

            if (jsonItems.length) {
                var item = jsonItems[0];
                context.withPath("items", index, function () {
                    nodeFromJson(node, item, context);
                });
            }
        });
        list.freeze(false);
    }

    function resetList(list, json, options, context) {
        var items = json.map(function (json, index) {
            if (!options.nodeType && !options.getNodeType) {
                return json;
            }

            if (!validNodeJson(json)) {
                throw new Error("invalid json source");
            }

            var nodeType = getNodeType(options, json);
            var cached = getCached(context, json.id, nodeType);
            if (cached) {
                return cached;
            }

            return context.withPath("items", index, function () {
                var nodes = list.items.filter(function (item) {
                    return item.id === json.id;
                });
                var node;
                if (nodes.length) {
                    node = nodes[0];
                    nodeFromJson(node, json, context);
                } else {
                    node = createChild(context, json, nodeType);
                }
                return node;
            });
        });

        items.unshift(0, -1);
        list.freeze(true);
        list.splice.apply(list, items);
        list.freeze(false);
    }

    function listFromJson(list, json, options, context) {
        if (options.preserve) {
            console.error("preserving", json);
            listFromJsonPreserving(list, json, context);
            return;
        }

        console.error("resetting", json);
        resetList(list, json, options, context);
    }

    var Node = defineType(function (id, version) {
        if (!isNaturalNumber(id) || !isNaturalNumber(version)) {
            throw new Error(isNaturalNumber(id) ?
                "invalid version" : "invalid id");
        }

        this.freeze(true);
        this.jsonOptions = Object.assign(this.jsonOptions || {}, {
            handlers: {ignore: true},
            jsonOptions: {ignore: true},
            frozen: {ignore: true},
            frozenEvents: {ignore: true}
        });
        this.id = id;
        this.version = version;
        try {
            this.freeze(false);
        } catch (error) {
            if (error.message !== "id cannot be changed") {
                throw error;
            }
        }
    }, new Notifier, {
        id: 0,
        version: 0,

        onchange: function (e) {
            switch (e.property) {
            case "id":
                throw new Error("id cannot be changed");
                break;
            case "version":
                break;
            default:
                console.error("version bump from", this.version, e.property);
                this.version++;
                break;
            }
        },

        fromJson: function (json) {
            var context = new JsonContext;
            nodeFromJson(this, json, context);
            console.error("freezing");
            this.freeze(true);
            context.applyIdOnly(this);
            this.freeze(false);
            console.error("unfreezed");
        },

        toJson: function () {
            return nodeToJson(this, new JsonContext);
        }
    });

    var List = defineType(new NotifierList, {
        fromJson: function (json) {
            if (!Array.isArray(json)) {
                throw new Error("invalid json source");
            }

            listFromJson(this, json, {preserve: false}, new JsonContext);
        },

        toJson: function () {
            return listToJson(this, {}, new JsonContext);
        }
    });

    var idFeed = 0;

    function createNode(Type) {
        var id = new Date().getTime() + (idFeed++);
        return new Type(id, 0);
    };

    Node.List = List;
    Node.create = createNode;

    module.exports = Node;
})();
